Die Boost C++ Bibliotheken


Kapitel 7: Asynchrone Ein- und Ausgabe


Inhaltsverzeichnis

Dieses Buch ist unter einer Creative Commons-Lizenz lizensiert.


7.1 Allgemeines

In diesem Kapitel, das sich um die asynchrone Ein- und Ausgabe von Daten dreht, wird die Boost C++ Bibliothek Asio behandelt. Ihr Name ist dabei Programm: Asio steht für asynchrones Input/Output. Mit dieser Bibliothek wird es in C++ möglich, Daten plattformunabhängig asynchron zu verarbeiten. Dabei versteht man unter der asynchronen Datenverarbeitung, Operationen anzustoßen, ohne auf ihren Abschluss zu warten. Stattdessen informiert Boost.Asio ein Programm, wenn eine Operation abgeschlossen wurde. Der Vorteil ist, dass in der Zwischenzeit andere Operationen ausgeführt werden können, ohne dass eine Funktion das Programm blockiert, weil auf ihren Abschluss gewartet wird.

Typische Beispiele für asynchrone Operationen finden sich in Netzwerkprogrammen. Sollen zum Beispiel Daten übers Internet versendet werden, möchte man üblicherweise wissen, ob die Daten auch erfolgreich versendet wurden und beim Empfänger ankamen. Ohne eine Bibliothek wie Boost.Asio würde man dazu den Rückgabewert einer Funktion auswerten. Das würde jedoch bedeuten, warten zu müssen, bis die Daten gesendet werden konnten und eine Empfangsbestätigung oder Fehlermeldung vorliegt. Mit Boost.Asio kann der Datenversand stattdessen in zwei Schritten erfolgen: Im ersten Schritt wird der Datenversand asynchron angestoßen. Wurden die Daten versendet und liegt eine Empfangsbestätigung oder Fehlermeldung vor, wird das Programm in einem zweiten Schritt über den Abschluss des Datenversands informiert. Der kann sowohl positiv als auch negativ sein. Der entscheidende Vorteil aber ist, dass das Programm nicht warten muss, bis der Datenversand abgeschlossen ist, sondern zwischenzeitlich andere Operationen ausführen kann.


7.2 I/O Services und I/O Objekte

Programme, die Boost.Asio zur asynchronen Datenverarbeitung verwenden, basieren auf sogenannten I/O Services und I/O Objekten. Während I/O Services Betriebssystemschnittstellen abstrahieren, die erst die asynchrone Datenverarbeitung ermöglichen, werden I/O Objekte verwendet, um bestimmte Operationen anzustoßen. Während Boost.Asio für den I/O Service lediglich eine einzige Klasse boost::asio::io_service zur Verfügung stellt, die für jedes Betriebssystem jeweils optimal implementiert ist, bietet die Bibliothek zahlreiche Klassen für unterschiedliche I/O Objekte an. Zu diesen zählt zum Beipiel die Klasse boost::asio::ip::tcp::socket, über die Daten über ein Netzwerk gesendet und empfangen werden können, oder die Klasse boost::asio::deadline_timer, die eine Art Wecker darstellt, der entweder zu einem bestimmten Zeitpunkt oder nach Ablauf einer bestimmten Zeitspanne klingelt. Der Timer wird im folgenden ersten Beispiel verwendet, da er im Gegensatz zu vielen anderen von Asio zur Verfügung gestellten I/O Objekten keine zusätzlichen Kenntnisse wie in der Netzwerkprogrammierung voraussetzt.

#include <boost/asio.hpp> 
#include <iostream> 

void handler(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

int main() 
{ 
  boost::asio::io_service io_service; 
  boost::asio::deadline_timer timer(io_service, boost::posix_time::seconds(5)); 
  timer.async_wait(handler); 
  io_service.run(); 
} 

In der Funktion main() wird zuerst ein I/O Service io_service definiert, mit dem das I/O Objekt timer initialisiert wird. So wie boost::asio::deadline_timer erwarten typischerweise alle I/O Objekte als ersten Parameter im Konstruktor einen I/O Service. Da timer einen Wecker darstellt, kann dem Konstruktor von boost::asio::deadline_timer ein zweiter Parameter übergeben werden, der einen Zeitpunkt oder eine Zeitdauer angibt, nach deren Ablauf der Wecker klingeln soll. Im obigen Beispiel wird angegeben, dass der Wecker nach fünf Sekunden klingeln soll. Die Zeit beginnt ab der timer-Definition zu laufen.

Während es nun möglich wäre, eine Funktion aufzurufen, die nach fünf Sekunden zurückkehrt, wenn der Wecker läutet, kann mit Asio eine asynchrone Operation gestartet werden. Dazu wird die Methode async_wait() aufgerufen, der als einziger Parameter der Name der Funktion handler() übergeben wird. Beachten Sie, dass handler() nicht aufgerufen wird, sondern tatsächlich nur der Funktionsname als Parameter angegeben wird.

Der Vorteil von async_wait() ist: Der Funktionsaufruf kehrt sofort zurück. Anstatt nun fünf Sekunden zu warten, bis der Wecker läutet, wird stattdessen nach fünf Sekunden die Funktion aufgerufen, deren Name als Parameter an async_wait() übergeben wurde. Das Programm kann also nun nach dem Aufruf von async_wait() etwas anderes tun als einfach nur anzuhalten und zu warten.

Eine Methode wie async_wait() wird als nicht-blockierend bezeichnet. Üblicherweise bieten I/O Objekte auch blockierende Methoden an, falls tatsächlich einmal gewartet werden soll, bis eine Operation abgeschlossen wurde. So kann zum Beispiel für boost::asio::deadline_timer die blockierende Methode wait() aufgerufen werden. Da die Methode blockiert, wird ihr auch kein Funktionsname übergeben. Sie kehrt einfach zu einem bestimmten Zeitpunkt oder nach Ablauf einer Zeitspanne zurück.

Wenn Sie sich den Quellcode im obigen Programm ansehen, stellen Sie fest, dass nach dem Aufruf von async_wait() eine Methode run() für den I/O Service aufgerufen wird. Dies ist zwingend notwendig, da die betriebssystemeigenen Funktionen irgendwie die Kontrolle übernehmen und nach fünf Sekunden die Funktion handler() aufrufen müssen.

Während async_wait() eine asynchrone Operation startet und sofort zurückkehrt, blockiert run(). Das Programm hält quasi beim Aufruf von run() an. Der Grund ist der, dass viele Betriebssysteme asynchrone Operationen ironischerweise nur über eine blockierende Funktion unterstützen. Warum das in der Praxis üblicherweise kein Problem ist, sehen Sie anhand des folgenden Programms.

#include <boost/asio.hpp> 
#include <iostream> 

void handler1(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

void handler2(const boost::system::error_code &ec) 
{ 
  std::cout << "10 s." << std::endl; 
} 

int main() 
{ 
  boost::asio::io_service io_service; 
  boost::asio::deadline_timer timer1(io_service, boost::posix_time::seconds(5)); 
  timer1.async_wait(handler1); 
  boost::asio::deadline_timer timer2(io_service, boost::posix_time::seconds(10)); 
  timer2.async_wait(handler2); 
  io_service.run(); 
} 

Im obigen Programm werden nun zwei I/O Objekte vom Typ boost::asio::deadline_timer verwendet. Das erste I/O Objekt repräsentiert einen Wecker, der nach fünf Sekunden, das zweite einen Wecker, der nach zehn Sekunden klingelt. Nach Ablauf der Zeiten werden die beiden Funktionen handler1() und handler2() aufgerufen, deren Namen an async_wait() übergeben wurden.

Auch in diesem Beispiel wird am Ende der Funktion main() die Methode run() für den einzigen I/O Service aufgerufen. Wie bereits erwähnt hält diese Methode das Programm an - sie ist blockierend. Die Methode übergibt die Steuerung an Betriebssystemfunktionen, die die asynchrone Datenverarbeitung übernehmen. Mit deren Hilfe wird die Funktion handler1() nach fünf Sekunden und die Funktion handler2() nach zehn Sekunden aufgerufen.

Es mag auf den ersten Blick verwundern, dass die asynchrone Datenverarbeitung den Aufruf einer Methode run() erfordert, die blockiert und das Programm anhält. Das ist aber insofern kein Problem als dass das Programm sowieso irgendwie davor bewahrt werden muss, beendet zu werden. Würde run() nicht blockieren, würde die Funktion main() zu Ende laufen und somit das gesamte Programm beendet werden. Für den Fall, dass man nicht auf die Rückkehr des Methodenaufrufes warten möchte und das Programm weiterlaufen soll, muss run() lediglich in einem neuen Thread aufgerufen werden. Denn run() hält selbstverständlich nicht das gesamte Programm an, sondern lediglich den aktuellen Thread.

Dass die Beispielprogramme dennoch irgendwann beendet werden, liegt daran, dass run() zurückkehrt, wenn der I/O Service, für den diese Methode aufgerufen wurde, nichts mehr zu tun hat. Für die obigen Programme bedeutet dies, dass sie dann beendet werden, wenn alle Wecker geklingelt haben. Denn dann gibt es keine asynchronen Operationen mehr, die auf ihren Abschluss warten.


7.3 Skalierbarkeit und Multithreading

Wenn man auf eine Bibliothek wie Boost.Asio zugreift, entwickelt man ein Programm anders als man es üblicherweise in C++ gewohnt ist. Es werden nicht mehr Funktionen nacheinander aufgerufen, die unter Umständen längere Zeit brauchen, bis sie zurückkehren. Anstatt blockierende Funktionen aufzurufen werden bei Boost.Asio asynchrone Operationen gestartet. Funktionen, die im Anschluss aufgerufen werden müssen, werden in den Handler gepackt, der aufgerufen wird, wenn die asynchrone Operation abgeschlossen wurde. Dabei werden Funktionsaufrufe, die nacheinander ausgeführt werden sollen, im Code räumlich getrennt, was grundsätzlich das Verständnis des Codes erschwert.

Eine Bibliothek wie Boost.Asio wird typischerweise dann eingesetzt, wenn eine höhere Effizienz erreicht werden soll. Ein Programm soll nicht mehr warten, bis eine Funktion abgeschlossen wurde, sondern soll zwischenzeitlich anderes tun können - zum Beispiel eine andere Funktion starten, deren Ausführung längere Zeit dauern kann.

Skalierbarkeit bezeichnet die Eigenschaft eines Programms, dass es von zusätzlichen Ressourcen, die zur Verfügung gestellt werden, tatsächlich profitieren kann. Die Verwendung von Boost.Asio an sich ist bereits empfehlenswert, wenn länger dauernde Operationen andere Operationen nicht aufhalten sollen. Auf heutigen Computern, die typischerweise mit Mehrkernprozessoren ausgerüstet sind, bietet sich darüberhinaus jedoch die Verwendung von Threads an. Dies kann ein auf Boost.Asio basierendes Programm wesentlich besser skalierbar machen.

Wenn die Methode run() für ein Objekt vom Typ boost::asio::io_service aufgerufen wird, erfolgt der Aufruf von Handlern im gleichen Thread, in dem run() aufgerufen wurde. Verwendet ein Programm mehrere Threads, kann in jedem Thread ein Aufruf von run() erfolgen. Der I/O Service wird dann, wenn eine asynchrone Operation abgeschlossen wird, den Handler in einem dieser Threads ausführen. Sollte kurz danach eine zweite asynchrone Operation abgeschlossen sein, kann der I/O Service den entsprechenden Handler in einem anderen zur Verfügung stehenden Thread starten. Der Vorteil ist, dass der I/O Service in diesem Fall nicht warten muss, bis der erste Handler beendet ist.

#include <boost/asio.hpp> 
#include <boost/thread.hpp> 
#include <iostream> 

void handler1(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

void handler2(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

boost::asio::io_service io_service; 

void run() 
{ 
  io_service.run(); 
} 

int main() 
{ 
  boost::asio::deadline_timer timer1(io_service, boost::posix_time::seconds(5)); 
  timer1.async_wait(handler1); 
  boost::asio::deadline_timer timer2(io_service, boost::posix_time::seconds(5)); 
  timer2.async_wait(handler2); 
  boost::thread thread1(run); 
  boost::thread thread2(run); 
  thread1.join(); 
  thread2.join(); 
} 

Das Beispiel aus dem vorherigen Abschnitt wird nun in ein Multithreaded-Programm umgewandelt. In der Funktion main() werden mit Hilfe der Klasse boost::thread, die zur Boost C++ Bibliothek Thread gehört und in der Headerdatei boost/thread.hpp definiert ist, zwei Threads erstellt. In beiden Threads wird lediglich die Methode run() für den einzigen I/O Service aufgerufen. Damit erhält der I/O Service die Möglichkeit, beide Threads zu nutzen, um Handler auszuführen, die bei Abschluss asynchroner Operationen aufgerufen werden müssen.

Wenn Sie sich den Quellcode im obigen Programm genau angesehen haben, ist Ihnen aufgefallen, dass beide Wecker nach fünf Sekunden klingeln sollen. Dadurch, dass zwei Threads zur Verfügung stehen, können die beiden Funktionen handler1() und handler2() tatsächlich gleichzeitig ausgeführt werden. Für den Fall also, dass der zweite Wecker klingelt und momentan noch der Handler des ersten Weckers ausgeführt wird, wird der Handler im zweiten zur Verfügung stehenden Thread ausgeführt. Sollte der Handler des ersten Weckers bereits beendet worden sein, steht es dem I/O Service frei, einen der beiden zur Verfügung stehenden Threads für den zweiten Handler zu verwenden.

Sie können mit Threads die Performance Ihres Programms erhöhen. Da Threads auf Prozessorkernen ausgeführt werden, sollten Sie maximal so viele Threads erstellen wie Prozessorkerne zur Verfügung stehen. So kann jeder Thread auf einem Prozessorkern ausgeführt werden, ohne dass sich Threads abwechseln und um Prozessorkerne streiten müssen.

Beachten Sie, dass der Einsatz von Threads nicht immer Sinn macht. Wenn Sie obiges Programm laufen lassen, kann es sein, dass die beiden Meldungen auf die Standardausgabe nicht nacheinander erscheinen, sondern gemischt werden. Das Problem ist, dass die beiden Handler, die möglicherweise gleichzeitig in zwei Threads ausgeführt werden, auf eine einzige Ressource zugreifen und sie sich teilen müssen: Die Standardausgabe std::cout. Um sicherzustellen, dass eine Meldung vollständig ausgegeben wird, ohne dass gleichzeitig ein anderer Thread eine Meldung auf die Standardausgabe auszugeben versucht, müsste der Zugriff synchronisiert werden. Die Verwendung von Threads würde also nicht viel Sinn machen, wenn Handler nicht wirklich unabhängig voneinander laufen können, sondern Ressourcen teilen und daher synchronisiert werden müssen.

Der mehrfache Aufruf von run() eines I/O Services ist die empfohlene Vorgehensweise, um ein Programm, das auf Boost.Asio basiert, skalierbar zu machen. Es gibt jedoch auch eine andere Möglichkeit: Statt mehrere Threads an einen I/O Service zu binden können mehrere I/O Services instantiiert werden. Jeder I/O Service verwendet dann nur einen Thread. Stehen jedoch genauso viele I/O Services wie Prozessorkerne zur Verfügung, können asynchrone Operationen jeweils auf ihrem eigenen Prozessorkern ausgeführt werden.

#include <boost/asio.hpp> 
#include <boost/thread.hpp> 
#include <iostream> 

void handler1(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

void handler2(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

boost::asio::io_service io_service1; 
boost::asio::io_service io_service2; 

void run1() 
{ 
  io_service1.run(); 
} 

void run2() 
{ 
  io_service2.run(); 
} 

int main() 
{ 
  boost::asio::deadline_timer timer1(io_service1, boost::posix_time::seconds(5)); 
  timer1.async_wait(handler1); 
  boost::asio::deadline_timer timer2(io_service2, boost::posix_time::seconds(5)); 
  timer2.async_wait(handler2); 
  boost::thread thread1(run1); 
  boost::thread thread2(run2); 
  thread1.join(); 
  thread2.join(); 
} 

Das aus diesem Kapitel bereits bekannte Beispiel mit den zwei Weckern wurde nun umgeschrieben und verwendet zwei I/O Services. Das Programm basiert immer noch auf zwei Threads, wobei nun aber jeweils ein Thread an einen I/O Service gebunden ist. Die beiden I/O Objekte timer1 und timer2 sind nun auch nicht mehr an den gleichen I/O Service gebunden, sondern an unterschiedliche.

Das obige Programm funktioniert grundsätzlich wie das vorherige, das lediglich einen I/O Service verwendet. Es kann unter Umständen von Vorteil sein, wenn mehrere I/O Services verwendet werden, denen jeweils ein Thread gehört und die idealerweise auf ihrem eigenen Prozessorkern laufen, weil alle asynchronen Operationen inklusive Handler lokal ablaufen. Wenn keine Zugriffe auf Daten oder Funktionen notwendig sind, die weit entfernt sind, funktioniert jeder I/O Service wie ein kleines selbständiges Programm. Die Adjektive lokal und weit entfernt beziehen sich in diesem Zusammenhang auf Ressourcen wie Cache und Speicherseiten. Da sehr detaillierte Hardware-, Betriebssystem- und Compiler-Kenntnisse notwendig sind und es je nach Programm unterschiedliche Flaschenhälse gibt, die erst identifiziert werden müssen, bevor über Strategien zur Optimierung des Programms nachgedacht werden kann, sollten mehrere I/O Services nur dann verwendet werden, wenn es klare Hinweise gibt, dass ein Programm von ihnen profitiert.


7.4 Netzwerkprogrammierung

Auch wenn Boost.Asio eine Bibliothek ist, mit der ganz allgemein Daten asynchron verarbeitet werden können, wird sie in der Praxis häufig zur Netzwerkprogrammierung eingesetzt. Das liegt daran, dass Boost.Asio Netzwerkfunktionen zuerst unterstützt hat, bevor im Laufe der Zeit neue I/O Objekte hinzukamen. Netzwerkfunktionen sind insofern ein gutes Beispiel für die asynchrone Datenverarbeitung, da die Datenübertragung über Netzwerke unter Umständen einige Zeit dauern kann und Ergebnisse wie Empfängsbestätigungen oder Fehlermeldungen nicht sofort vorliegen.

Boost.Asio bietet zahlreiche I/O Objekte an, um Netzwerkanwendungen zu entwickeln. Im Folgenden lernen Sie die Klasse boost::asio::ip::tcp::socket kennen, über die Sie eine Verbindung zu einem anderen Computer aufbauen können. Im Beispiel wird die Highscore-Homepage heruntergeladen - etwas, was Ihr Browser auch tut, wenn Sie auf den Link www.highscore.de klicken.

#include <boost/asio.hpp> 
#include <boost/array.hpp> 
#include <iostream> 
#include <string> 

boost::asio::io_service io_service; 
boost::asio::ip::tcp::resolver resolver(io_service); 
boost::asio::ip::tcp::socket sock(io_service); 
boost::array<char, 4096> buffer; 

void read_handler(const boost::system::error_code &ec, std::size_t bytes_transferred) 
{ 
  if (!ec) 
  { 
    std::cout << std::string(buffer.data(), bytes_transferred) << std::endl; 
    sock.async_read_some(boost::asio::buffer(buffer), read_handler); 
  } 
} 

void connect_handler(const boost::system::error_code &ec) 
{ 
  if (!ec) 
  { 
    boost::asio::write(sock, boost::asio::buffer("GET / HTTP 1.1\r\nHost: highscore.de\r\n\r\n")); 
    sock.async_read_some(boost::asio::buffer(buffer), read_handler); 
  } 
} 

void resolve_handler(const boost::system::error_code &ec, boost::asio::ip::tcp::resolver::iterator it) 
{ 
  if (!ec) 
  { 
    sock.async_connect(*it, connect_handler); 
  } 
} 

int main() 
{ 
  boost::asio::ip::tcp::resolver::query query("www.highscore.de", "80"); 
  resolver.async_resolve(query, resolve_handler); 
  io_service.run(); 
} 

Was Ihnen sofort auffallen sollte, ist, dass drei Handler im Programm verwendet werden: Die Funktionen connect_handler() und read_handler() werden aufgerufen, wenn die Verbindung erstellt wird und Daten empfangen werden. Wozu aber wird die Funktion resolve_handler() benötigt?

Im Internet werden Computer über sogenannte IP-Adressen identifiziert. Es handelt sich hierbei um mehr oder weniger lange Nummern, die schwer zu merken sind. Namen wie www.highscore.de machen den Umgang mit Computern im Alltag wesentlich einfacher. Dies setzt aber voraus, dass ein Name wie www.highscore.de in eine IP-Adresse umgewandelt werden kann. Diesen Vorgang bezeichnet man als Namensauflösung - oder in Englisch name resolution. Die Namensauflösung wird dabei von einem sogenannten name resolver durchgeführt. Das erklärt den Namen des entsprechenden I/O Objekts: boost::asio::ip::tcp::resolver.

Die Namensauflösung ist ein Vorgang, der seinerseits die Kontaktaufnahme zu einem Computer im Internet erfordert. Sogenannte DNS-Server, die Sie sich als elektronische Telefonbücher vorstellen können, wissen, welchem Computer welche IP-Adresse zugeordnet ist. Der Vorgang der Namensauflösung an sich ist insofern transparent als dass Sie sich nicht um die technischen Details kümmern müssen. Es ist lediglich wichtig zu verstehen, warum die Namensauflösung notwendig ist und warum deswegen ein I/O Objekt boost::asio::ip::tcp::resolver verwendet werden muss. Da die Namensauflösung nicht lokal erfolgt, sondern mit Hilfe der bereits erwähnten DNS-Server, und somit einen Moment dauern kann, ist der Vorgang ebenfalls als asynchrone Operation implementiert. Die Funktion resolve_handler() wird also dann aufgerufen, wenn die Namensauflösung erfolgte oder aber mit einer Fehlermeldung beendet wurde.

Dadurch, dass der Empfang von Daten eine erfolgreiche Verbindungsaufnahme voraussetzt und eine Verbindungsaufnahme eine erfolgreiche Namensauflösung, werden verschiedene asynchrone Operationen in Handlern gestartet. So wird in der Funktion resolve_handler() auf das I/O Objekt sock zugegriffen, um mit Hilfe der aufgelösten Adresse, die über den Iterator it zur Verfügung steht, eine Verbindung aufzubauen. In der Funktion connect_handler() wird wiederum auf sock zugegriffen, um einen HTTP-Request zu senden und anschließend den Datenempfang zu starten. Dadurch, dass es sich bei allen Funktionen um asynchrone Operationen handelt, werden jeweils die Namen von Handlern als Parameter weitergegeben. Je nach Funktion sind zusätzliche Parameter notwendig wie bespielsweise der Iterator it, der auf die aufgelöste Adresse zeigt, oder der Puffer buffer, in dem empfangene Daten gespeichert werden.

Wenn das Programm startet, wird in der Funktion main() ein Objekt query vom Typ boost::asio::ip::tcp::resolver::query erstellt, das eine Anfrage darstellt. Diese Anfrage bestehend aus dem Namen www.highscore.de und dem im WWW üblicherweise verwendeten Port 80 wird an die Methode async_resolve() des Resolvers übergeben, um den Namen aufzulösen. Anschließend wird in der Funktion main() lediglich run() für den I/O Service aufgerufen, um die Kontrolle über die asynchronen Operationen ans Betriebssystem zu übergeben.

Wurde der Name aufgelöst, wird der Handler resolve_handler() aufgerufen. In diesem wird überprüft, ob die Namensauflösung erfolgreich war. Ist sie das, ist das Objekt ec, das Fehlerarten repräsentiert, auf 0 gesetzt. Nur in diesem Fall wird auf den Socket zugegriffen und der Verbindungsaufbau initiiert. Die Adresse des Servers, zu dem die Verbindung aufgebaut wurde, steht über den zweiten Funktionsparameter vom Typ boost::asio::ip::tcp::resolver::iterator zur Verfügung. Dies ist das Ergebnis der asynchronen Namensauflösung.

Dem Aufruf von async_connect() folgt ein Aufruf des Handlers connect_handler(). Dort wird ebenfalls ein Objekt ec ausgewertet, um zu überprüfen, ob der Verbindungsaufbau erfolgreich war. Ist dies der Fall, wird die Methode async_read_some() für den Socket aufgerufen. Mit diesem Methodenaufruf beginnt der Lesevorgang über die nun bestehende Verbindung. Da empfangene Daten irgendwo gespeichert werden müssen, muss ein Puffer als erster Parameter an async_read_some() übergeben werden. Im obigen Beispiel handelt es sich um eine Variable vom Typ boost::array. Diese Klasse gehört zur Boost C++ Bibliothek Array und ist in der Headerdatei boost/array.hpp definiert.

Die Funktion read_handler() wird aufgerufen, wenn ein oder mehr Bytes empfangen und im Puffer buffer gespeichert wurden. Der Parameter bytes_transferred vom Typ std::size_t gibt jeweils an, wie viele Bytes genau im Puffer empfangen wurden. Wie üblich sollte auch in diesem Handler zuerst der Parameter ec ausgewertet werden, um zu überprüfen, ob möglicherweise ein Empfangsfehler vorliegt. Ist dies nicht der Fall, werden die empfangenen Daten einfach auf die Standardausgabe ausgegeben.

Beachten Sie, dass innerhalb des Handlers read_handler() nach der Datenausgabe über std::cout wieder die Methode async_read_some() für den Socket aufgerufen wird. Das ist notwendig, da nicht davon ausgegangen werden kann, dass die gesamte Homepage über eine einzige asynchrone Operation empfangen werden kann und vollständig im Puffer buffer vorliegt. Der wiederholte Aufruf von async_read_some() gefolgt von einem wiederholten Aufruf des Handlers read_handler() endet erst dann, wenn die Verbindung unterbrochen wird. Dies geschieht, wenn der Webserver die Homepage komplett versendet hat. In diesem Fall wird im Handler read_handler() ein Fehler gemeldet, so dass keine Datenausgabe über std::cout erfolgt und auch async_read() für den Socket nicht mehr aufgerufen wird. Da es nun keine ausstehenden asynchronenen Operationen mehr gibt, endet das Programm.

Während das eben vorgestellte Programm verwendet wurde, um die Homepage von www.highscore.de abzurufen, lernen Sie im Folgenden ein Programm kennen, das einen sehr einfachen Webserver darstellt. Der entscheidende Unterschied ist, dass das folgende Programm keine Verbindung zu einem anderen Computer aufbaut, sondern darauf wartet, dass andere Computer zu ihm eine Verbindung aufbauen.

#include <boost/asio.hpp> 
#include <string> 

boost::asio::io_service io_service; 
boost::asio::ip::tcp::endpoint endpoint(boost::asio::ip::tcp::v4(), 80); 
boost::asio::ip::tcp::acceptor acceptor(io_service, endpoint); 
boost::asio::ip::tcp::socket sock(io_service); 
std::string data = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!"; 

void write_handler(const boost::system::error_code &ec, std::size_t bytes_transferred) 
{ 
} 

void accept_handler(const boost::system::error_code &ec) 
{ 
  if (!ec) 
  { 
    boost::asio::async_write(sock, boost::asio::buffer(data), write_handler); 
  } 
} 

int main() 
{ 
  acceptor.listen(); 
  acceptor.async_accept(sock, accept_handler); 
  io_service.run(); 
} 

Das I/O Objekt boost::asio::ip::tcp::acceptor wird verwendet, um auf einen Verbindungsaufbau ausgehend von einem anderen Computer zu warten. Dazu muss das entsprechende Objekt, im obigen Programm acceptor, derart initialisiert werden, dass es weiß, über welches Protokoll und über welchen Port ein möglicher Verbindungsaufbau erfolgt. Mit dem Objekt endpoint vom Typ boost::asio::ip::tcp::endpoint wird im obigen Programm eingestellt, dass der Acceptor auf Port 80 auf eingehende Verbindung vom Typ des Internet-Protokolls 4 warten soll, was der üblicherweise im WWW verwendete Port und das im WWW verwendete Protokoll ist.

Nachdem der Acceptor initialisiert wurde, wird in der Funktion main() zuerst listen() aufgerufen, um den Acceptor in den Empfangsmodus zu setzen. Anschließend wird mit async_accept() auf die erste Verbindungsaufnahme gewartet. Dazu muss ein Socket als erster Parameter an async_accept() übergeben werden, über den dann der Datenversand und -empfang erfolgt.

Nimmt ein anderer Computer Verbindung auf, wird die Funktion accept_handler() aufgerufen. War der Verbindungsaufbau erfolgreich, wird die freistehende Funktion boost::asio::async_write() verwendet, um sämtliche Daten im String data über den Socket zu verschicken. Die Klasse boost::asio::ip::tcp::socket bietet zwar auch eine Methode async_write_some() an. Bei dieser Methode wird ein Handler aber immer dann aufgerufen, wenn mindestens ein Byte gesendet wurde. Es müsste also im Handler berechnet werden, wie viele Bytes noch gesendet werden müssen. Diese müssten über einen wiederholten Aufruf von async_write_some() auf den Weg geschickt werden. Berechnungen und wiederholte Aufrufe von async_write_some() lassen sich vermeiden, wenn boost::asio::async_write() verwendet wird, da diese asynchrone Operation erst dann als abgeschlossen gilt, wenn alle Daten im Puffer gesendet wurden.

Nach dem Versand aller Daten wird die Funktion write_handler() aufgerufen. Diese Funktion ist leer, da nach dem Datenversand nichts weiter passieren soll. Da es keine ausstehenden asynchronen Operationen gibt, endet das Programm. Gleichzeitig wird die Verbindung zum anderen Computer beendet.


7.5 Eigene Boost.Asio-Erweiterungen entwickeln

Während Boost.Asio heute vor allem Netzwerkfunktionen unterstützt, möchten Sie möglicherweise andere asynchrone Operationen ausführen. Dies ist insofern kein Problem als dass Sie Boost.Asio um neue I/O Objekte erweitern können. Im Folgenden lernen Sie den grundsätzlichen Aufbau einer Boost.Asio-Erweiterung kennen. Dieser Aufbau ist nicht zwingend vorgeschrieben, stellt aber ein brauchbares Gerüst für jede Art von Erweiterung dar.

Grundsätzlich entwickeln Sie drei Klassen, wenn Sie neue asynchrone Operationen zu Boost.Asio hinzufügen möchten:

  • Eine Klasse, die von boost::asio::basic_io_object abgeleitet wird, repräsentiert das neue I/O Objekt. Entwickler, die Ihre Boost.Asio-Erweiterung einsetzen werden, werden aussschließlich mit dem I/O Objekt in Berührung kommen.

  • Eine Klasse, die von boost::asio::io_service::service abgeleitet wird, ist ein Dienst, der im I/O Service installiert wird und auf den von einem I/O Objekt zugegriffen wird. Der Grund, warum zwischen dem I/O Objekt und dem Dienst unterschieden wird, ist, dass es jeweils nur eine einzige Instanz eines Dienstes pro I/O Service gibt, aber unter Umständen mehrere I/O Objekte, die auf den Dienst zugreifen.

  • Eine Klasse, die von keiner anderen abgeleitet werden muss, stellt die Dienst-Implementation dar. Weil wie gerade erfahren pro I/O Service nur eine einzige Instanz des Dienstes existiert, erstellt dieser Dienst für jedes I/O Objekt eine Instanz der Dienst-Implementation. Dort werden dann die zum I/O Objekt gehörenden internen Daten verwaltet, die für asynchrone Operationen vonnöten sind.

Sie lernen im Folgenden zuerst den grundsätzlichen Aufbau eines I/O Objekts kennen. Damit die Boost.Asio-Erweiterung auch kompiliert und getestet werden kann und nicht nur ein leeres Gerüst bleibt, ist sie dem in Boost.Asio enthaltenen Timer boost::asio::deadline_timer nachempfunden. Ein entscheidender Unterschied zwischen boost::asio::deadline_timer und der im Folgenden zu entwickelnden Boost.Asio-Erweiterung ist, dass die Zeitspanne, nach dessen Ablauf der Wecker klingen soll, nicht im Konstruktor übergeben wird, sondern immer dann, wenn wait() oder async_wait() aufgerufen wird.

#include <boost/asio.hpp> 
#include <cstddef> 

template <typename Service> 
class basic_timer 
  : public boost::asio::basic_io_object<Service> 
{ 
  public: 
    explicit basic_timer(boost::asio::io_service &io_service) 
      : boost::asio::basic_io_object<Service>(io_service) 
    { 
    } 

    void wait(std::size_t seconds) 
    { 
      return this->service.wait(this->implementation, seconds); 
    } 

    template <typename Handler> 
    void async_wait(std::size_t seconds, Handler handler) 
    { 
      this->service.async_wait(this->implementation, seconds, handler); 
    } 
}; 

Ein I/O Objekt ist typischerweise als Template-Klasse implementiert, die mit einem Dienst instantiiert werden muss - üblicherweise mit dem Dienst, der speziell für das I/O Objekt entwickelt wurde. Wenn ein I/O Objekt instantiiert wird, wird der Dienst von der Elternklasse boost::asio::basic_io_object im I/O Service installiert - außer er wurde bereits zuvor im I/O Service installiert. So ist sichergestellt, dass es von jedem Dienst, den I/O Objekte benötigen, maximal eine Instanz pro I/O Service gibt.

Der zum I/O Objekt gehörende Dienst steht über eine Referenz namens service im I/O Objekt zur Verfügung. Das I/O Objekt greift typischerweise auf diese Referenz zu, um Methodenaufrufe an den Dienst weiterzuleiten. Da ein Dienst normalerweise Daten zu jedem I/O Objekt speichern muss, wird für jedes I/O Objekt automatisch eine Instanz der Dienst-Implementation erstellt. Dies geschieht ebenfalls mit Hilfe der Elternklasse boost::asio::basic_io_object. Damit der Dienst weiß, welches I/O Objekt jeweils auf ihn zugreift und Methodenaufrufe weiterleitet, wird die Dienst-Implementation beim Methodenaufruf als Parameter übergeben. Auf die Dienst-Implementation eines I/O Objekts kann über die Eigenschaft implementation zugegriffen werden.

Wie Sie sehen ist ein I/O Objekt recht einfach gestrickt: Während die Installation eines Dienstes sowie das Erstellen einer Dienst-Implementation von der Elternklasse boost::asio::basic_io_object übernommen wird, werden Methodenaufrufe einfach an den entsprechenden Dienst weitergeleitet, wobei als ein Parameter die Dienst-Implementation des I/O Objekts weitergereicht wird.

#include <boost/asio.hpp> 
#include <boost/thread.hpp> 
#include <boost/bind.hpp> 
#include <boost/scoped_ptr.hpp> 
#include <boost/shared_ptr.hpp> 
#include <boost/weak_ptr.hpp> 
#include <boost/system/error_code.hpp> 

template <typename TimerImplementation = timer_impl> 
class basic_timer_service 
  : public boost::asio::io_service::service 
{ 
  public: 
    static boost::asio::io_service::id id; 

    explicit basic_timer_service(boost::asio::io_service &io_service) 
      : boost::asio::io_service::service(io_service), 
      async_work_(new boost::asio::io_service::work(async_io_service_)), 
      async_thread_(boost::bind(&boost::asio::io_service::run, &async_io_service_)) 
    { 
    } 

    ~basic_timer_service() 
    { 
      async_work_.reset(); 
      async_io_service_.stop(); 
      async_thread_.join(); 
    } 

    typedef boost::shared_ptr<TimerImplementation> implementation_type; 

    void construct(implementation_type &impl) 
    { 
      impl.reset(new TimerImplementation()); 
    } 

    void destroy(implementation_type &impl) 
    { 
      impl->destroy(); 
      impl.reset(); 
    } 

    void wait(implementation_type &impl, std::size_t seconds) 
    { 
      boost::system::error_code ec; 
      impl->wait(seconds, ec); 
      boost::asio::detail::throw_error(ec); 
    } 

    template <typename Handler> 
    class wait_operation 
    { 
      public: 
        wait_operation(implementation_type &impl, boost::asio::io_service &io_service, std::size_t seconds, Handler handler) 
          : impl_(impl), 
          io_service_(io_service), 
          work_(io_service), 
          seconds_(seconds), 
          handler_(handler) 
        { 
        } 

        void operator()() const 
        { 
          implementation_type impl = impl_.lock(); 
          if (impl) 
          { 
              boost::system::error_code ec; 
              impl->wait(seconds_, ec); 
              this->io_service_.post(boost::asio::detail::bind_handler(handler_, ec)); 
          } 
          else 
          { 
              this->io_service_.post(boost::asio::detail::bind_handler(handler_, boost::asio::error::operation_aborted)); 
          } 
      } 

      private: 
        boost::weak_ptr<TimerImplementation> impl_; 
        boost::asio::io_service &io_service_; 
        boost::asio::io_service::work work_; 
        std::size_t seconds_; 
        Handler handler_; 
    }; 

    template <typename Handler> 
    void async_wait(implementation_type &impl, std::size_t seconds, Handler handler) 
    { 
      this->async_io_service_.post(wait_operation<Handler>(impl, this->get_io_service(), seconds, handler)); 
    } 

  private: 
    void shutdown_service() 
    { 
    } 

    boost::asio::io_service async_io_service_; 
    boost::scoped_ptr<boost::asio::io_service::work> async_work_; 
    boost::thread async_thread_; 
}; 

template <typename TimerImplementation> 
boost::asio::io_service::id basic_timer_service<TimerImplementation>::id; 

Ein Dienst muss mehrere Bedingungen erfüllen, um in Boost.Asio integriert werden zu können:

  • Er muss von der Klasse boost::asio::io_service::service abgeleitet sein. Der Konstruktor muss eine Referenz auf einen I/O Service erwarten, der dann als einziger Parameter an den Konstruktor von boost::asio::io_service::service weitergereicht wird.

  • Jeder Dienst muss eine öffentliche statische Eigenschaft id vom Typ boost::asio::io_service::id besitzen. Über diese Eigenschaft werden Dienste im I/O Service identifiziert.

  • Zwei öffentliche Methoden construct() und destruct() müssen definiert sein, die einen Parameter vom Typ implementation_type erwarten. Bei implementation_type handelt es sich typischerweise um eine Typdefinition für die Dienst-Implementation. Häufig wird wie im obigen Code zusätzlich boost::shared_ptr verwendet. Auf diese Weise kann recht einfach in construct() eine Dienst-Implementation instantiiert und in destruct() zerstört werden. Da diese beiden Methoden automatisch aufgerufen werden, wenn ein I/O Objekt erzeugt und zerstört wird, kann ein Dienst in construct() und destruct() für jedes I/O Objekt entsprechend Dienst-Implementationen erzeugen und zerstören.

  • Es muss eine Methode shutdown_service() definiert sein, die privat sein darf. Bei Boost.Asio-Erweiterungen ist die Methode typischerweise leer und macht nichts. Sie wird lediglich von Diensten benötigt, die stärker in Boost.Asio integriert sind. Die Methode shutdown_service() muss dennoch vorhanden sein, weil der Code sonst nicht kompiliert.

Da I/O Objekte Methodenaufrufe an den Dienst weiterleiten, müssen entsprechende Methoden zur Weiterleitung definiert werden. Diese heißen üblicherweise ähnlich wie die Methoden des I/O Objekts - im obigen Beispiel wait() und async_wait(). Während eine synchrone Operation wie wait() lediglich auf die Dienst-Implementation zugreift, um eine blockierende Methode aufzurufen, erfolgt dies für eine asynchrone Operation wie async_wait() mit Hilfe eines Threads. Der Trick bei asynchronen Operationen ist also der, dass eine blockierende Funktion einfach in einem Thread aufgerufen wird.

Um die asynchrone Operation mit Hilfe eines Threads zu modellieren, wird üblicherweise auf einen neuen I/O Service zugegriffen. Wenn Sie sich den Dienst oben ansehen, stellen Sie fest, dass er eine Eigenschaft async_io_service_ vom Typ boost::asio::io_service besitzt. Die Methode run() dieses I/O Services wird in einem eigenen Thread gestartet, der mit async_thread_ vom Typ boost::thread erstellt wird. Dies geschieht im Konstruktor des Dienstes. Die dritte Eigenschaft async_work_ vom Typ boost::scoped_ptr<boost::asio::io_service::work> wird benötigt, um zu verhindern, dass run() sofort zurückkehrt. Immerhin gibt es anfangs keine ausstehenden asynchronen Operationen, so dass der I/O Service async_io_service_ nichts zu tun hat. Indem ein Objekt vom Typ boost::asio::io_service::work erstellt und mit dem I/O Service verbunden wird - auch dies geschieht im Konstruktor des Services - wird verhindert, dass run() sofort beendet wird.

Ein Service könnte auch implementiert werden, ohne auf einen eigenen I/O Service zuzugreifen - ein Thread allein wäre völlig ausreichend. Der Grund, warum man im Zusammenhang mit einem Thread zusätzlich einen neuen I/O Service verwendet, ist der, dass über diesen I/O Service sehr einfach Threads miteinander kommunizieren können. Wenn Sie sich die Methode async_wait() ansehen: Dort wird ein Funktionsobjekt vom Typ wait_operation erstellt und mit post() an den internen I/O Service weitergegeben. Auf das Funktionsobjekt wird dann in dem Thread, in dem zuvor run() für den internen I/O Service aufgerufen wurde, zugegriffen und der überladene Operator operator()() aufgerufen. Die Methode post() ermöglicht es also recht einfach, ein Funktionsobjekt in einem anderen Thread auszuführen.

Im überladenen Operator operator()() von wait_operation passiert grundsätzlich das gleiche wie in wait(): Es wird auf die Dienst-Implementation zugegriffen und eine blockierende Methode wait() aufgerufen. Es gilt jedoch zu berücksichtigen, dass ein I/O Objekt und damit seine Dienst-Implementation zerstört werden kann, während im Thread gerade der überladene Operator operator()() ausgeführt wird. Für den Fall, dass quasi zeitgleich in destruct() die Dienst-Implementation zerstört wird, darf in operator()() nicht auf diese zugegriffen werden. Hier kommt der aus dem ersten Kapitel bekannte weak pointer zum Einsatz: Der weak pionter impl_ gibt einen shared pointer auf die Dienst-Implementation zurück, wenn diese zum Zeitpunkt des Aufrufes von lock() existiert. Sollte die Dienst-Implementation bereits in destruct() zerstört worden sein, ist der von lock() zurückgegebene shared pointer 0. In diesem Fall versucht operator()() nicht, auf die Dienst-Implementation zuzugreifen, sondern ruft den Handler mit dem Fehler boost::asio::error::operation_aborted auf.

#include <boost/system/error_code.hpp> 
#include <cstddef> 
#include <windows.h> 

class timer_impl 
{ 
  public: 
    timer_impl() 
      : handle_(CreateEvent(NULL, FALSE, FALSE, NULL)) 
    { 
    } 

    ~timer_impl() 
    { 
      CloseHandle(handle_); 
    } 

    void destroy() 
    { 
      SetEvent(handle_); 
    } 

    void wait(std::size_t seconds, boost::system::error_code &ec) 
    { 
      DWORD res = WaitForSingleObject(handle_, seconds * 1000); 
      if (res == WAIT_OBJECT_0) 
        ec = boost::asio::error::operation_aborted; 
      else 
        ec = boost::system::error_code(); 
    } 

private: 
    HANDLE handle_; 
}; 

Die Dienst-Implementation timer_impl oben basiert auf Windows API-Funktionen und kann daher nur unter Windows kompiliert und eingesetzt werden. Sie soll auch lediglich beispielhaft verdeutlichen, wie sie implementiert werden könnte.

Die Klasse timer_impl besitzt zwei wesentliche Methoden: wait() wird aufgerufen, um für eine oder mehrere Sekunden zu warten. Mit destroy() kann ein Wartevorgang unterbrochen werden. Dies ist deswegen notwendig, weil wie gesehen für asynchrone Operationen wait() in einem eigenen Thread aufgerufen wird. Sollte das I/O Objekt und damit die Dienst-Implementation jedoch zerstört werden, muss die blockierende Methode wait() möglichst schnell unterbrochen werden. Dies geschieht über den Aufruf von destroy().

Die Boost.Asio-Erweiterung könnte nun wie folgt eingesetzt werden.

#include <boost/asio.hpp> 
#include <iostream> 
#include "basic_timer.hpp" 
#include "timer_impl.hpp" 
#include "basic_timer_service.hpp" 

void wait_handler(const boost::system::error_code &ec) 
{ 
  std::cout << "5 s." << std::endl; 
} 

typedef basic_timer<basic_timer_service<> > timer; 

int main() 
{ 
  boost::asio::io_service io_service; 
  timer t(io_service); 
  t.async_wait(5, wait_handler); 
  io_service.run(); 
} 

Wenn Sie dieses Beispielprogramm mit dem vergleichen, was Sie zu Beginn dieses Kapitels kennengelernt haben, stellen Sie fest, dass die Boost.Asio-Erweiterung ähnlich wie boost::asio::deadline_timer verwendet wird. In der Praxis sollten Sie auf alle Fälle boost::asio::deadline_timer vorziehen, nachdem dieses I/O Objekt in Boost.Asio integriert ist. Das soeben entwickelte I/O Objekt sollte Sie lediglich in die Entwicklung von Boost.Asio-Erweiterungen einführen, so dass Sie Boost.Asio um neue asynchrone Funktionen erweitern können.

Wenn Sie sich Quellcode einer Boost.Asio-Erweiterung, die in der Praxis eingesetzt wird, ansehen möchten, so können Sie zum Beispiel den Directory Monitor herunterladen. Es handelt sich dabei um ein I/O Objekt, mit dem Verzeichnisse überwacht werden können. Wird eine Datei in einem überwachten Verzeichnis angelegt, geändert oder gelöscht, wird ein Handler aufgerufen. Die aktuelle Version des Directory Monitors wird unter Windows und unter Linux (ab der Kernel-Version 2.6.13) unterstützt.


7.6 Aufgaben

Sie können die Lösungen zu allen Aufgaben in diesem Buch als ZIP-Datei erwerben.

  1. Ändern Sie den Server aus Abschnitt 7.4, „Netzwerkprogrammierung“ dahingehend, dass er nicht mehr nach eine Anfrage beendet wird, sondern beliebig viele Anfragen bearbeiten kann.

  2. Erweitern Sie den Client aus Abschnitt 7.4, „Netzwerkprogrammierung“ dahingehend, dass der empfangene HTML-Code jeweils sofort nach einer URL durchsucht wird. Wird eine URL gefunden, soll die entsprechende Ressource ebenfalls sofort heruntergeladen werden. Verwenden Sie für diese Aufgabe die erste URL, die Sie im HTML-Code finden. Idealerweise speichern Sie die Webseite und die zusätzlich heruntergeladene Ressource in zwei Dateien ab anstatt die empfangenen Daten alle auf die Standardausgabe auszugeben.

  3. Erstellen Sie eine Client/Server-Anwendung, mit der eine Datei zwischen zwei Computern übertragen werden kann. Wird der Server gestartet, soll er die IP-Adressen aller lokalen Schnittstellen anzeigen und darauf warten, dass ein Client eine Verbindung aufbaut. Wenn ein Client gestartet wird, soll ihm eine der vom Server angebotenen IP-Adressen und der Name einer lokalen Datei als Kommandozeilenparameter übergeben werden. Der Client soll dann die Datei zum Server übertragen, wo sie gespeichert wird. Während der Datenübertragung soll der Client einen Verlauf anzeigen, so dass ein Anwender weiß, dass die Datenübertragung momentan im Gange ist.